// Package serde contains Pachyderm-specific data structures for marshalling and
// unmarshalling Go structs and maps to structured text formats (currently just
// JSON and YAML).
//
// Similar to https://github.com/ghodss/yaml, all implementations of the Format
// interface marshal and unmarshal data using the following pipeline:
//
//	Go struct/map (fully structured)
//	  <-> JSON document
//	  <-> map[string]interface{}
//	  <-> target format document
//
// Despite the redundant round of marshalling and unmarshalling, there are two
// main advantages to this approach:
//   - YAML (and any future storage formats) can re-use existing `json:` struct
//     tags
//   - The intermediate map[string]interface{} can be manipulated, making it
//     possible to have flexible converstions between structs and documents. For
//     examples, PPS pipelines may include a full TFJob spec, which is converted
//     to a string and stored in the 'TFJob' field of Pachyderm's
//     CreatePipelineRequest struct.
package serde

import (
	"io"
	"strings"

	"google.golang.org/protobuf/proto"

	"github.com/pachyderm/pachyderm/v2/src/internal/errors"
)

// EncoderOption modifies the behavior of new encoders and can be passed to
// GetEncoder.
type EncoderOption func(Encoder)

// WithOrigName is an EncoderOption that, if set, encodes proto messages using
// the name set in the original .proto message definition, rather than the
// munged version of the generated struct's field name
func WithOrigName(origName bool) func(d Encoder) {
	return func(d Encoder) {
		switch e := d.(type) {
		case *YAMLEncoder:
			e.origName = origName
		case *JSONEncoder:
			e.origName = origName
		}
	}
}

// WithIndent is an EncoderOption that causes the returned encoder to use
// 'numSpaces' spaces as the indentation prefix for embedded messages. If
// applied to a JSON encoder, it also changes the encoder output to be
// multi-line (instead of inline).
func WithIndent(numSpaces int) func(d Encoder) {
	return func(d Encoder) {
		switch e := d.(type) {
		case *YAMLEncoder:
			e.e.SetIndent(numSpaces)
		case *JSONEncoder:
			e.e.SetIndent("", strings.Repeat(" ", numSpaces))
			e.indentSpaces = numSpaces // used by EncodeProto shortcut
		}
	}
}

// GetEncoder dynamically creates and returns an Encoder for the text format
// 'encoding' (currently, 'encoding' must be "yaml" or "json").
// 'defaultEncoding' specifies the text format that should be used if 'encoding'
// is "". 'opts' are the list of options that should be applied to any result,
// if any are applicable.  Typically EncoderOptions are encoder-specific (e.g.
// setting json indentation). If an option is passed to GetEncoder for e.g. a
// json encoder but a yaml encoder is requested, then the option will be
// ignored. This makes it possible to pass all options to GetEncoder, but defer
// the decision about what kind of encoding to use until runtime, like so:
//
// enc, _ := GetEncoder(outputFlag, os.Stdout,
//
//	...options to use if json...,
//	...options to use if yaml...,
//
// )
// enc.Encode(obj)
func GetEncoder(encoding string, w io.Writer, opts ...EncoderOption) (Encoder, error) {
	switch encoding {
	case "yaml":
		return NewYAMLEncoder(w, opts...), nil
	case "json":
		return NewJSONEncoder(w, opts...), nil
	default:
		return nil, errors.Errorf("unrecognized encoding: %q (must be \"yaml\" or \"json\")", encoding)
	}
}

// Encoder is an interface for encoding data to an output stream (every
// implementation should provide the ability to construct an Encoder tied to an
// output stream, to which encoded text should be written)
type Encoder interface {
	// Marshall converts 'v' (a struct or Go map) to a structured-text document
	// and writes it to this Encoder's output stream
	Encode(v interface{}) error

	// EncodeProto is similar to Encode, but instead of converting between the canonicalized
	// JSON and 'v' using 'encoding/json', it does so using
	// 'google.golang.org/protobuf/encoding/protojson'.  This allows callers to take advantage
	// of more sophisticated timestamp parsing and such.
	//
	// TODO(msteffen): We can *almost* avoid the Encode/EncodeProto split by checking if 'v'
	// implements 'proto.Message', except for one case: the kubernetes client library includes
	// structs that are pseudo-protobufs.  Structs in the kubernetes go client implement the
	// 'proto.Message()' interface but are hand-generated and contain embedded structs, which
	// 'jsonpb' can't handle when parsing. If jsonpb is ever extended to be able to parse JSON
	// into embedded structs (even though the protobuf compiler itself never generates such
	// structs) then we could merge this into Encode() and rely on:
	//
	//   if msg, ok := v.(proto.Message); ok {
	//     ... use jsonpb ...
	//   } else {
	//     ... use encoding/json ...
	//   }
	EncodeProto(proto.Message) error

	// EncodeTransform is similar to Encode, but users can manipulate the intermediate
	// map[string]interface generated by Format implementations by passing a function. Note that
	// 'Encode(v)' is equivalent to 'EncodeTransform(v, nil)'
	EncodeTransform(interface{}, func(map[string]interface{}) error) error

	// EncodeProtoTransform is similar to EncodeTransform(), but instead of converting between
	// the canonicalized JSON and 'v' using 'encoding/json', it does so using
	// 'google.golang.org/protobuf/encoding/protojson'.  This allows callers to take advantage
	// advantage of more sophisticated timestamp parsing and such in the 'jsonpb' library.
	//
	// TODO(msteffen) same comment re: proto.Message as for EncodeProto()
	EncodeProtoTransform(proto.Message, func(map[string]interface{}) error) error
}
